File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/content-enforce.php
<?php
// includes/content-enforce.php
//
// Whole-post/page enforcement for the synced content-access rules (content-gates.php):
// content_immediate - the entire the_content result is replaced by a server-rendered gate.
// content_preview - a safe preview (manual excerpt, else plain-text 55-word fallback) is
// shown, followed by the gate.
//
// Protected content is never present in unauthorized HTML - no CSS/JS hiding, anywhere. The
// enforcement filter runs at the_content priority 0, before shortcode/embed/block expansion,
// so protected shortcodes, embeds and scripts never execute for an unauthorized reader.
// Secondary surfaces (feeds, REST, excerpts, oEmbed-via-excerpt, WPRM print + JSON-LD) are
// covered with their own supported hooks - no output buffering, no arbitrary HTML deletion.
//
// Authorization: the verified first-party member session must contain any of the required
// canonical product ids (OR rule), same authority as every other gate.
if (!defined('ABSPATH')) exit;
/* ---------------------------------------------------------------------------- gate lookup */
/*
* The active whole-content rule for a post, or null. Returns null (no enforcement) for:
* excluded posts (membership landing page, developer filter - handled inside
* allspice_content_gate_for_post), password-protected content (WordPress owns that flow),
* and WordPress editors who can edit the post (previews must work).
*/
function allspice_content_enforcement_rule($post_id) {
$post_id = (int)$post_id;
if ($post_id <= 0) return null;
/* Fail OPEN: a stale content-gate snapshot must never hide content once memberships are
disabled or not ready. */
if (!function_exists('allspice_gate_memberships_active') || !allspice_gate_memberships_active()) return null;
if (!function_exists('allspice_content_gate_for_post')) return null;
$rule = allspice_content_gate_for_post($post_id);
if (!is_array($rule) || empty($rule['product_ids'])) return null;
if (function_exists('post_password_required') && post_password_required($post_id)) return null;
if (function_exists('current_user_can') && current_user_can('edit_post', $post_id)) return null;
return $rule;
}
/* OR rule against the verified member session. */
function allspice_content_rule_authorized(array $rule): bool {
$ids = isset($rule['product_ids']) && is_array($rule['product_ids']) ? $rule['product_ids'] : [];
if ($ids === []) return false;
return function_exists('allspice_member_has_any_product') && allspice_member_has_any_product($ids);
}
/*
* True when THIS request renders a whole-content gate (unauthorized reader on a gated
* singular). membership-gate.php consults this so the recipe-card gate never renders
* alongside/inside a whole-content gate (Part 4).
*/
function allspice_content_gate_overrides_recipe(): bool {
if (!is_singular() || is_admin() || is_feed()) return false;
$rule = allspice_content_enforcement_rule((int)get_queried_object_id());
if ($rule === null) return false;
return !allspice_content_rule_authorized($rule);
}
/* ---------------------------------------------------------------------------- debugging */
/*
* Admin-only gate diagnostics: append ?allspice_gate_debug=1 to any frontend URL while
* logged in as an administrator and the request returns the exact whole-content decision
* inputs as JSON instead of the page. Runs on template_redirect (before ANY theme code), so
* it works even on themes whose templates never call the_content or wp_footer. Safe to keep:
* requires manage_options, and exposes only public config + the admin's own access state.
*/
add_action('template_redirect', 'allspice_content_gate_debug', 1);
function allspice_content_gate_debug(): void {
if (!isset($_GET['allspice_gate_debug'])) return;
if (!function_exists('current_user_can') || !current_user_can('manage_options')) return;
$id = is_singular() ? (int)get_queried_object_id() : 0;
$permalink = $id > 0 ? (string)get_permalink($id) : '';
$rule = $id > 0 ? allspice_content_gate_for_post($id) : null;
wp_send_json([
'is_singular' => is_singular(),
'post_id' => $id,
'post_type' => $id > 0 ? get_post_type($id) : null,
'permalink' => $permalink,
'url_key' => ($permalink !== '' && function_exists('allspice_content_gate_key'))
? allspice_content_gate_key($permalink) : null,
'stored_gate_keys' => function_exists('allspice_content_gates_get')
? array_keys(allspice_content_gates_get()) : null,
'rule_for_this_post' => $rule,
'memberships_configured' => function_exists('allspice_memberships_configured')
? allspice_memberships_configured() : null,
'viewer_is_editor_bypassed' => $id > 0 && current_user_can('edit_post', $id),
'rule_after_editor_bypass' => $id > 0 ? allspice_content_enforcement_rule($id) : null,
'viewer_member_authorized' => is_array($rule) ? allspice_content_rule_authorized($rule) : null,
]);
}
/* ---------------------------------------------------------------------------- previews */
/*
* SAFE preview text (content_preview). Manual WordPress excerpt when authored; otherwise a
* plain-text preview generated from RAW post_content WITHOUT ever running it through
* apply_filters('the_content') - blocks, shortcodes, embeds, scripts, forms, recipe cards and
* schema never execute. Truncation ends with an ellipsis.
*/
function allspice_content_preview_text($post): string {
$post = is_object($post) ? $post : (function_exists('get_post') ? get_post($post) : null);
if (!$post) return '';
$manual = trim((string)($post->post_excerpt ?? ''));
if ($manual !== '') {
return trim(wp_strip_all_tags($manual, true));
}
$raw = (string)($post->post_content ?? '');
/* Block content: keep only text-safe freeform content (drops embeds, forms, recipe-card
and interactive blocks) - core's own excerpt-safe reducer. */
if (function_exists('excerpt_remove_blocks')) {
$raw = excerpt_remove_blocks($raw);
} else {
$raw = preg_replace('/<!--\s*\/?wp:.*?-->/s', '', $raw);
}
/* Shortcodes must not execute OR leak their inner syntax. */
if (function_exists('strip_shortcodes')) {
$raw = strip_shortcodes($raw);
}
$raw = preg_replace('/\[[^\]\n]*\]/', '', $raw); // any leftover bracket syntax
/* Scripts/styles/forms including their contents. */
$raw = preg_replace('#<(script|style|form|iframe|object|embed|textarea|button)\b[^>]*>.*?</\1>#is', '', $raw);
$raw = wp_strip_all_tags($raw, true);
/* Bare URLs on their own line are classic-embed leftovers. */
$lines = array_filter(array_map('trim', explode("\n", $raw)), static function ($line) {
return $line !== '' && !preg_match('#^https?://\S+$#i', $line);
});
$text = trim(implode(' ', $lines));
$limit = (int)apply_filters('allspice_content_preview_word_limit', 55, $post);
if ($limit < 1) $limit = 55;
return wp_trim_words($text, $limit, '…');
}
/* ------------------------------------------------------------------------------ gate HTML */
/* Gate copy strictly by gate type, from the ONE normalizer in membership.php (schema v2
nested gate_ui / legacy gates[] both handled there). content_preview and content_immediate
each get their own copy - never shared with the recipe-card gate, never gates[0]. */
function allspice_content_gate_copy(string $gate_type): array {
return allspice_membership_gate_copy($gate_type);
}
/*
* Full PHP/JS gate contract: the hydrator (page bundle) reads type, canonical product ids
* (JSON array), url_key, config_version, and needs a real status target. One source for
* every whole-content gate.
*/
function allspice_content_gate_html(array $rule, int $post_id): string {
$gate_type = (string)($rule['gate_type'] ?? 'content_immediate');
$products = isset($rule['product_ids']) && is_array($rule['product_ids'])
? array_values(array_map('strval', $rule['product_ids'])) : [];
$permalink = function_exists('get_permalink') ? (string)get_permalink($post_id) : '';
$url_key = $permalink !== '' && function_exists('allspice_content_gate_key')
? allspice_content_gate_key($permalink) : '';
$cv = function_exists('allspice_memberships_config_version') ? allspice_memberships_config_version() : '';
$c = allspice_content_gate_copy($gate_type);
return '<div class="allspice-recipe-gate allspice-content-gate"'
. ' data-allspice-gate-type="' . esc_attr($gate_type) . '"'
. ' data-allspice-gate-products="' . esc_attr((string)wp_json_encode($products)) . '"'
. ' data-allspice-url-key="' . esc_attr($url_key) . '"'
. ' data-allspice-gate-config-version="' . esc_attr($cv) . '">'
. '<div class="allspice-recipe-gate__inner">'
. '<div class="allspice-recipe-gate__brand" data-allspice-gate-brand></div>'
. '<h3 class="allspice-recipe-gate__title">' . esc_html($c['title']) . '</h3>'
. '<p class="allspice-recipe-gate__copy">' . esc_html($c['copy']) . '</p>'
. (function_exists('allspice_membership_benefits_html')
? allspice_membership_benefits_html(allspice_membership_benefits_limit())
: '')
. allspice_membership_gate_actions_html($c['join_label'], $c['login_label'])
. '<div class="allspice-recipe-gate__status" data-allspice-gate-status aria-live="polite"></div>'
. '</div></div>';
}
/* Short generic replacement for secondary surfaces (feeds, REST, excerpts). */
function allspice_content_members_only_text(): string {
return (string)apply_filters('allspice_content_members_only_text', 'This content is for members.');
}
/* Item 2: a member currently READING unlocked whole-content gets a marker class, so the page
bundle can reload once after logout and restore the server-rendered gate. */
add_filter('body_class', 'allspice_content_member_unlocked_body_class', 2);
function allspice_content_member_unlocked_body_class(array $classes): array {
if (!is_singular() || is_admin()) return $classes;
$rule = allspice_content_enforcement_rule((int)get_queried_object_id());
if ($rule !== null && allspice_content_rule_authorized($rule)) {
$classes[] = 'allspice-member-content-unlocked';
}
return $classes;
}
/* --------------------------------------------------------------------------- the_content */
/*
* Priority 0: runs before wpautop/shortcodes/blocks, so for content_immediate the protected
* body is REPLACED before anything inside it can expand or execute. Theme-rendered title,
* featured image, author, date etc. live outside the_content and are untouched.
*/
add_filter('the_content', 'allspice_content_enforce_filter', 0);
function allspice_content_enforce_filter($content) {
if (!is_string($content)) return $content;
if (is_admin() || is_feed()) return $content; // feeds have their own hook
if (!is_singular()) return $content;
/* THEME COMPAT (live finding on the fitmencook `recipes` CPT template): custom singular
templates often render the_content from a secondary query or outside the formal loop,
so in_the_loop()/is_main_query() are false and a loop-state guard silently disables
gating. The real invariant we need is IDENTITY: the content being filtered is the
queried singular post's own content. Excerpt/related/widget passes carry a different
current post id and still fall through untouched. */
$post_id = (int)get_the_ID();
$queried = (int)get_queried_object_id();
if ($post_id <= 0 || $queried <= 0 || $post_id !== $queried) return $content;
$rule = allspice_content_enforcement_rule($post_id);
if ($rule === null) return $content;
if (allspice_content_rule_authorized($rule)) return $content; // member: untouched
if (allspice_content_gate_mode() === 'overlay') {
/* SOFT OVERLAY (product decision 2026-08-05, replacing removal as the default):
the full content SHIPS in the HTML; the gate renders as a clamp + fade + overlay
and the page bundle locks scroll when the gate reaches the viewport. This is a
deliberate soft paywall - trivially bypassable via view-source/reader mode, and
readable by crawlers (the JSON-LD below declares the lead-in paywall to Google).
Publishers who need hard protection switch back with:
add_filter('allspice_content_gate_mode', fn() => 'remove');
Feeds, excerpts, and REST stay PROTECTED (removal) in both modes - no overlay
exists on those surfaces. */
return allspice_content_soft_gate_html($rule, $post_id, $content);
}
if ($rule['gate_type'] === 'content_preview') {
$preview = allspice_content_preview_text(get_post($post_id));
$preview_html = $preview !== '' ? '<p>' . esc_html($preview) . '</p>' : '';
return $preview_html . "\n" . allspice_content_gate_html($rule, $post_id);
}
/* content_immediate: no article-body content before the gate. */
return allspice_content_gate_html($rule, $post_id);
}
/* 'overlay' (default) | 'remove' (the original secure engine - content absent from HTML). */
function allspice_content_gate_mode(): string {
$mode = apply_filters('allspice_content_gate_mode', 'overlay');
return $mode === 'remove' ? 'remove' : 'overlay';
}
/*
* Soft-overlay markup: clamped content (content_preview shows a tall lead-in, content_immediate
* only a teaser sliver), a fade into the gate background, then the STANDARD gate card (same
* copy/benefits/actions/hydrator contract as removal mode). The JSON-LD marks the clamped
* section as paywalled lead-in content per Google's paywall guidance, so shipping the body
* in HTML is not treated as cloaking.
*/
function allspice_content_soft_gate_html(array $rule, int $post_id, string $content): string {
$gate_type = (string)($rule['gate_type'] ?? 'content_immediate');
$variant = $gate_type === 'content_preview' ? 'preview' : 'immediate';
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'isAccessibleForFree' => false,
'hasPart' => [
'@type' => 'WebPageElement',
'isAccessibleForFree' => false,
'cssSelector' => '.allspice-paywalled-content',
],
];
return (function_exists('allspice_gate_embedded_css') ? allspice_gate_embedded_css() : '')
. '<div class="allspice-soft-gate allspice-soft-gate--' . esc_attr($variant) . '"'
. ' data-allspice-soft-gate="' . esc_attr($gate_type) . '">'
. '<div class="allspice-soft-gate__content allspice-paywalled-content">' . $content . '</div>'
. '<div class="allspice-soft-gate__fade" aria-hidden="true"></div>'
. allspice_content_gate_html($rule, $post_id)
. '<script type="application/ld+json">' . wp_json_encode($schema) . '</script>'
. '</div>';
}
/* ------------------------------------------------------------- secondary output surfaces */
/* Feeds: full-content feed + RSS excerpt. */
add_filter('the_content_feed', 'allspice_content_enforce_feed');
add_filter('the_excerpt_rss', 'allspice_content_enforce_feed');
function allspice_content_enforce_feed($content) {
$post_id = (int)get_the_ID();
$rule = allspice_content_enforcement_rule($post_id);
if ($rule === null || allspice_content_rule_authorized($rule)) return $content;
if ($rule['gate_type'] === 'content_preview') {
$p = allspice_content_preview_text(get_post($post_id));
return $p !== '' ? esc_html($p) : esc_html(allspice_content_members_only_text());
}
return esc_html(allspice_content_members_only_text());
}
/* Excerpts everywhere they surface (archives, search, related-post cards, oEmbed discovery). */
add_filter('get_the_excerpt', 'allspice_content_enforce_excerpt', 20, 2);
function allspice_content_enforce_excerpt($excerpt, $post = null) {
$post_id = $post ? (int)(is_object($post) ? $post->ID : $post) : (int)get_the_ID();
$rule = allspice_content_enforcement_rule($post_id);
if ($rule === null || allspice_content_rule_authorized($rule)) return $excerpt;
if ($rule['gate_type'] === 'content_preview') {
$p = allspice_content_preview_text(function_exists('get_post') ? get_post($post_id) : null);
return $p !== '' ? $p : allspice_content_members_only_text();
}
return allspice_content_members_only_text();
}
/* REST: posts + pages. Editors keep raw access; everyone else gets the gated shapes. */
add_filter('rest_prepare_post', 'allspice_content_enforce_rest', 20, 3);
add_filter('rest_prepare_page', 'allspice_content_enforce_rest', 20, 3);
function allspice_content_enforce_rest($response, $post = null, $request = null) {
if (!is_object($response) || !isset($response->data) || !is_array($response->data)) return $response;
$post_id = (int)(is_object($post) ? ($post->ID ?? 0) : 0);
$rule = allspice_content_enforcement_rule($post_id); // editors already excluded inside
if ($rule === null || allspice_content_rule_authorized($rule)) return $response;
$replacement = $rule['gate_type'] === 'content_preview'
? allspice_content_preview_text(function_exists('get_post') ? get_post($post_id) : $post)
: allspice_content_members_only_text();
if ($replacement === '') $replacement = allspice_content_members_only_text();
if (isset($response->data['content']) && is_array($response->data['content'])) {
$response->data['content']['rendered'] = '<p>' . esc_html($replacement) . '</p>';
if (array_key_exists('raw', $response->data['content'])) $response->data['content']['raw'] = '';
$response->data['content']['protected'] = true;
}
if (isset($response->data['excerpt']) && is_array($response->data['excerpt'])) {
$response->data['excerpt']['rendered'] = '<p>' . esc_html($replacement) . '</p>';
$response->data['excerpt']['protected'] = true;
}
return $response;
}
/*
* WPRM surfaces for a whole-content-gated recipe URL (best-effort; WPRM is third-party and
* not installed here - these are its documented public hooks, live-verification pending):
* - JSON-LD: `wprm_recipe_metadata` -> [] removes the recipe schema for unauthorized readers.
* - Print view: WPRM's print template runs through template_redirect with its own query;
* when the parent post is whole-content gated, send the reader to the post instead.
*/
add_filter('wprm_recipe_metadata', 'allspice_content_enforce_recipe_schema', 20, 2);
function allspice_content_enforce_recipe_schema($metadata, $recipe = null) {
if (!allspice_content_gate_overrides_recipe()) return $metadata;
return [];
}
/*
* WPRM print. Request shape (WPRM docs/source; LIVE VERIFICATION STILL PENDING - no WPRM
* install exists in this repo): pretty URL `/wprm_print/{recipe_id}` (optionally with a slug
* segment) or `?wprm_print={recipe_id}`, registered via WPRM's `wprm_print` rewrite/query
* var. That request is not the parent post's singular view - is_singular() checks say
* nothing there, so the parent is resolved DETERMINISTICALLY: the numeric recipe id from the
* query var / URI -> the wprm_recipe post -> its post_parent -> that post's content rule.
*/
function allspice_wprm_print_recipe_id(): int {
$raw = '';
if (isset($_GET['wprm_print'])) $raw = (string)$_GET['wprm_print'];
if ($raw === '' && function_exists('get_query_var')) $raw = (string)get_query_var('wprm_print');
if ($raw === '' && isset($_SERVER['REQUEST_URI'])
&& preg_match('#/wprm_print/(?:[^/]+/)?(\d+)#', (string)$_SERVER['REQUEST_URI'], $m)) {
$raw = $m[1];
}
if (preg_match('/(\d+)/', $raw, $m)) return (int)$m[1];
return 0;
}
function allspice_wprm_print_parent_post_id(): int {
$recipe_id = allspice_wprm_print_recipe_id();
if ($recipe_id <= 0 || !function_exists('get_post')) return 0;
$recipe = get_post($recipe_id);
if (!$recipe) return 0;
/* WPRM stores recipes as wprm_recipe posts whose post_parent is the article. */
$parent = (int)($recipe->post_parent ?? 0);
if ($parent > 0) return $parent;
/* Fallback: WPRM also records the parent on the recipe's meta. */
if (function_exists('get_post_meta')) {
$meta = (int)get_post_meta($recipe_id, 'wprm_parent_post_id', true);
if ($meta > 0) return $meta;
}
return 0;
}
add_action('template_redirect', 'allspice_content_enforce_wprm_print', 1);
function allspice_content_enforce_wprm_print(): void {
$recipe_id = allspice_wprm_print_recipe_id();
if ($recipe_id <= 0) return;
$parent_id = allspice_wprm_print_parent_post_id();
if ($parent_id <= 0) return; // cannot identify the parent deterministically: do nothing
$rule = allspice_content_enforcement_rule($parent_id);
if ($rule === null || allspice_content_rule_authorized($rule)) return;
$target = function_exists('get_permalink') ? get_permalink($parent_id) : home_url('/');
wp_safe_redirect($target ?: home_url('/'));
exit;
}